// hardenedApplication.js
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import crypto from 'crypto';
import https from 'https';
import { exec } from 'child_process';
import { PKI } from './pki.js';
import { readFile, log } from './utils.js';
import { generateHardwareId } from './hardware_id.js';
import { DEBUG, TICKET_DIR, DIST_DIR, DISTRIBUTION_SERVER_URL } from './config.js';
import { rainstormHash } from '@dosyago/rainsum';
import { revalidate } from './../../branch-bbx-revalidate.js'
import { sleep } from './../common.js';
import * as ed from '@noble/ed25519';

export const __filename = () => fileURLToPath(import.meta.url);
export const __dirname = () => path.dirname(__filename());

const MAX_REVALIDATE_RETRIES = 2;

/**
 * HardenedApplication class encapsulates application integrity and license management.
 * This version uses ESNext private fields, allowing a raw in-memory private key if desired.
 */
export class HardenedApplication {
  // Private fields
  #appDir;
  #privateKeyPath;
  #privateKeyPem;
  #ignorePatterns;
  #certificatePath;
  #licenseServerUrl;
  #apiVersion;
  #instanceId;
  #pki;

  /**
   * Constructs a new HardenedApplication instance.
   * @param {Object} options - Configuration options.
   * @param {string} options.appDir - Directory containing application files. (type: string) (required)
   * @param {string} [options.privateKeyPath] - Path to the private key for signing (if file-based).
   * @param {string} [options.privateKeyPem] - In-memory PEM string of the private key (if db-based).
   * @param {Array<string>} [options.ignorePatterns] - Patterns to ignore during file scanning.
   * @param {string} [options.certificatePath] - Path to the license certificate.
   * @param {string} [options.licenseServerUrl] - URL of the license server.
   * @param {string} [options.apiVersion] - API version for the license server.
   */
  constructor(options = {}) {
    const {
      appDir,
      privateKeyPath = null,
      privateKeyPem = null,
      ignorePatterns = [
        'my_custom_policy_module',
        'version.json',
        'package.json',
        '.bang.html.snapshot',
        '~',
        'README.txt',
        'README.md',
        'core',
        'nohup.out',
        'src/public/assets/ruffle',
        '.bbpro_install_dir',
        '.DS_Store',
        'node_modules',
        'package-lock.json',
        '.git/',
        '.sig',
        '.swp',
        '.log',
        'login_link.txt',
        'timechain_proof.json',
        'tickets.json',
        'ticket.json',
        'manifest.txt',
        'manifest.txt.sig',
        'certificate.json',
        'certificates.json',
        'src/public/voodoo/assets/icons'
      ],
      certificatePath = null,
      licenseServerUrl = null,
      apiVersion = 'v1',
    } = options;
    if (!appDir) {
      throw new Error('appDir is required.');
    }
    this.#appDir = path.resolve(appDir);
    this.#privateKeyPath = privateKeyPath; // If file-based
    this.#privateKeyPem = privateKeyPem; // If in-memory
    this.#ignorePatterns = ignorePatterns;
    this.#certificatePath = certificatePath;
    this.#licenseServerUrl = licenseServerUrl;
    this.#apiVersion = apiVersion;
    this.#instanceId = this.#generateInstanceId();
    this.#pki = new PKI();
  }

  /**
   * Generates a unique INSTANCE_ID for each application run.
   * @returns {string} - A unique hexadecimal string.
   */
  #generateInstanceId() {
    return `DOSAYGO://browserbox/${generateHardwareId().hwid}/${crypto.randomBytes(16).toString('hex')}`;
  }

  /**
   * Create a manifest of all included files and sign it with the provided private key (file-based or memory-based).
   */
  async createManifestAndSign() {
    const privateKey = this.#getSigningKey();
    if (!privateKey) {
      throw new Error('No private key available for signing.');
    }
    const manifestPath = path.join(__dirname(), 'manifest.txt');
    // Gather file hashes
    const entries = await this.#hashAllFiles();
    // Write manifest
    const manifestContent = entries.map(e => `${e.hash} ${e.relativePath}`).join('\n');
    fs.writeFileSync(manifestPath, manifestContent, 'utf-8');
    console.log({manifestContent});
    // Sign the manifest
    const signature = this.#signData(Buffer.from(manifestContent, 'utf-8'), privateKey);
    fs.writeFileSync(manifestPath + '.sig', signature.toString('hex'), 'utf-8');
    console.log('Manifest created and signed successfully.');
  }

  /**
   * Verify the manifest and ensure all files match their recorded hashes.
   * Also verify the signature of the manifest against the root public key from the distribution server.
   */
  async verifyManifest() {
    // Fetch the root public key from the distribution server
    const response = await fetchWithTimeoutAndRetry(`${DISTRIBUTION_SERVER_URL}/keys/root`, {
      method: 'GET',
      agent: new https.Agent({ rejectUnauthorized: true })
    });
    if (!response.ok) {
      throw new Error(`Failed to fetch root public key: ${response.status}`);
    }
    const { key: publicKey } = await response.json();
    if (!publicKey) {
      throw new Error('Received empty public key from server');
    }
    const manifestPath = path.join(__dirname(), 'manifest.txt');
    const sigPath = manifestPath + '.sig';
    if (!fs.existsSync(manifestPath) || !fs.existsSync(sigPath)) {
      throw new Error('Manifest or signature file missing.');
    }
    const manifestContent = fs.readFileSync(manifestPath, 'utf-8').trim();
    const manifestSignature = Buffer.from(fs.readFileSync(sigPath, 'utf-8'), 'hex');
    // Verify the manifest signature first
    const isManifestValid = this.#verifySignature(
      Buffer.from(manifestContent, 'utf-8'),
      manifestSignature,
      publicKey
    );
    if (!isManifestValid) {
      throw new Error('Manifest signature verification failed.');
    }
    // Now, re-hash all files and compare with manifest
    const currentEntries = await this.#hashAllFiles();
    const currentContent = currentEntries.map(e => `${e.hash} ${e.relativePath}`).join('\n');
    if (currentContent.trim() !== manifestContent.trim()) {
      // If they differ, either a file changed or something is tampered
      try {
        fs.writeFileSync(path.resolve(TICKET_DIR, 'calculated-manifest.txt'), currentContent.trim());
      } catch(e) {
        console.warn(`Could not write calculated manifest.`, e);
      }
      throw new Error('Current file hashes do not match the manifest.');
    }
    console.log('Manifest verified successfully. All files match and signature is valid.');
    return true;
  }

  /**
   * Hash all files except ignored patterns using rainstormHash(256)
   * Returns an array of {relativePath, hash}
   */
  async #hashAllFiles() {
    const files = [];
    this.#walkDir(this.#appDir, (filePath) => {
      files.push(filePath);
    });
    const results = [];
    for (const f of files) {
      const data = fs.readFileSync(f);
      const hash = await rainstormHash(256, 0, data);
      const relativePath = path.relative(this.#appDir, f).replace(/\\/g, '/');
      results.push({ relativePath, hash });
    }
    // Sort entries to ensure consistent ordering
    results.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
    return results;
  }

  /**
   * Recursively walks through a directory and executes a callback on each file.
   * @param {string} dir - Directory path.
   * @param {Function} callback - Function to execute on each file.
   */
  #walkDir(dir, callback) {
    const files = fs.readdirSync(dir);
    for (const file of files) {
      const fullPath = path.join(dir, file);
      const relativePath = path.relative(this.#appDir, fullPath).replace(/\\/g, '/');
      // Skip ignored patterns
      if (this.#shouldIgnore(relativePath)) {
        continue;
      } else {
        process.env.BBX_VERBOSE_DEBUG && console.log(`Including ${relativePath} / (${fullPath}) in manifest.`);
      }
      const stat = fs.statSync(fullPath);
      if (stat.isDirectory()) {
        this.#walkDir(fullPath, callback);
      } else if (stat.isFile()) {
        callback(fullPath);
      }
    }
  }

  /**
   * Determines whether a file should be ignored based on the ignore patterns.
   * @param {string} relativePath - The relative path of the file.
   * @returns {boolean} - True if the file should be ignored, false otherwise.
   */
  #shouldIgnore(relativePath) {
    for (const pattern of this.#ignorePatterns) {
      if (this.#matchPattern(relativePath, pattern)) {
        process.env.BBX_VERBOSE_DEBUG && console.log(`Ignoring ${relativePath} based on ${pattern}`);
        return true;
      }
    }
    return false;
  }

  /**
   * Simple pattern matching function (supports '*' wildcard).
   * @param {string} text - The text to match.
   * @param {string} pattern - The pattern to match against.
   * @returns {boolean} - True if the pattern matches the text, false otherwise.
   */
  #matchPattern(text, pattern) {
    const escapedPattern = pattern
      .replace(/[-\/\\^$+?.()|[\]{}]/g, '\\$&')
      .replace(/\*/g, '.*');
    const regex = new RegExp(`${escapedPattern}`, 'i');
    return regex.test(text);
  }

  /**
   * Signs data using the provided private key.
   * @param {Buffer|string} data - The data to sign.
   * @param {string} privateKey - The PEM-formatted private key.
   * @returns {Buffer} - The signature.
   */
  #signData(data, privateKey) {
    const sign = crypto.createSign('SHA256');
    sign.update(data);
    sign.end();
    return sign.sign(privateKey);
  }

  /**
   * Verifies the signature of the data using the provided public key.
   * @param {Buffer|string} data - The original data.
   * @param {Buffer|string} signature - The signature.
   * @param {string} publicKey - The PEM-formatted public key.
   * @returns {boolean} - True if the signature is valid, false otherwise.
   */
  #verifySignature(data, signature, publicKey) {
    const verify = crypto.createVerify('SHA256');
    verify.update(data);
    verify.end();
    return verify.verify(publicKey, signature);
  }

  /**
   * Retrieve the signing key from memory or file, whichever is set.
   * @returns {string|null} - The PEM-formatted private key or null if not found.
   */
  #getSigningKey() {
    if (this.#privateKeyPem) {
      return this.#privateKeyPem; // In-memory key
    }
    if (this.#privateKeyPath && fs.existsSync(this.#privateKeyPath)) {
      return fs.readFileSync(this.#privateKeyPath, 'utf8'); // File-based key
    }
    return null;
  }

  /**
   * Checks the license by performing local PKI validation and communicating with the license server.
   * @throws Will throw an error if license validation fails.
   */
  async checkLicense({ targets, integrity, attempt = 0 } = {}) {
    try {
      if (!this.#certificatePath || !this.#licenseServerUrl) {
        throw new Error('License validation configuration is incomplete.');
      }

      const hwfp = generateHardwareId();
      hwfp.integrity = { integrity };

      // Load & locally validate cert
      console.log({ certPath: this.#certificatePath });
      const certificateJson = readFile(this.#certificatePath);
      const fullChain = JSON.parse(certificateJson);
      const isValidLocal = await this.#pki.validateTicket(fullChain);
      if (!isValidLocal) {
        throw new Error('Local certificate validation failed.');
      }

      // If a reservation exists, REDEEM (no revalidateOnly). Otherwise revalidate-only.
      let reservationCode = null;
      try {
        const reservationFile = path.join(TICKET_DIR, 'reservation.json');
        if (fs.existsSync(reservationFile)) {
          const resJson = JSON.parse(fs.readFileSync(reservationFile, 'utf8'));
          reservationCode = resJson?.reservationCode || null;
        }
      } catch (e) {
        console.warn('Failed to read reservation.json in checkLicense:', e);
      }

      // Challenge-response authentication flow
      let challengeNonce = null;
      let nonceSignature = null;
      let useChallenge = true;

      try {
        // Step 1: Request challenge nonce from server
        const seatId = fullChain.seatCertificate?.seatData?.seatId;
        if (!seatId) {
          throw new Error('Cannot extract seatId from certificate chain');
        }

        console.log('[checkLicense] Requesting challenge nonce for seatId:', seatId);
        const challengeResponse = await fetchWithTimeoutAndRetry(
          `${this.#licenseServerUrl}/tickets/challenge`,
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ seatId }),
            agent: new https.Agent({ rejectUnauthorized: true }),
          }
        );

        if (!challengeResponse.ok) {
          throw new Error(`Failed to fetch challenge nonce: ${challengeResponse.status}`);
        }

        const challengeData = await challengeResponse.json();
        challengeNonce = challengeData.nonce;

        if (!challengeNonce) {
          throw new Error('Server did not provide a challenge nonce');
        }

        console.log('[checkLicense] Received challenge nonce');

        // Step 2: Sign the nonce with the ticket's Ed25519 private key
        const ticketPrivateKeyBase64 = fullChain.ticket?.ticketData?.jwk?.d;
        if (!ticketPrivateKeyBase64) {
          throw new Error('Ticket private key not found in certificate chain');
        }

        // Convert base64url to Buffer for Ed25519 signing
        const ticketPrivateKey = Buffer.from(ticketPrivateKeyBase64, 'base64url');
        const signature = await ed.signAsync(Buffer.from(challengeNonce, 'utf8'), ticketPrivateKey);
        nonceSignature = Buffer.from(signature).toString('hex');
        console.log('[checkLicense] Signed challenge nonce');
      } catch (err) {
        console.warn('[checkLicense] Challenge-response setup failed:', err.message);
        // Fallback to non-challenge mode if challenge fails
        useChallenge = false;
        challengeNonce = null;
        nonceSignature = null;
      }

      const payload = reservationCode
        ? { certificateJson: fullChain, instanceId: this.#instanceId, reservationCode } // redeem
        : { certificateJson: fullChain, instanceId: this.#instanceId, revalidateOnly: true }; // revalidate-only

      // Add challenge-response data if available
      if (useChallenge && challengeNonce) {
        payload.challengeNonce = challengeNonce;
        payload.nonceSignature = nonceSignature;
      }

      console.log('[checkLicense] reservationCode present?', Boolean(reservationCode));
      console.log('[checkLicense] Using challenge-response?', useChallenge);

      const response = await fetchWithTimeoutAndRetry(
        `${this.#licenseServerUrl}/tickets/validate`,
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ ...payload, targets, hwfp }),
          agent: new https.Agent({ rejectUnauthorized: true }),
        }
      );

      let result;
      try {
        result = await response.text();
        result = JSON.parse(result);
      } catch {
        console.warn({ result });
        throw new Error(`License server produced an invalid response with code ${response.status}`);
      }

      console.log('DEBUG: Server validation response details:', {
        status: response.status,
        err: result.err,
        message: result.message
      });

      if (!response.ok) {
        // If the server indicates we need redemption or code, fall back to the full validate path once.
        if (
          (result?.err === 'reserved_during_reval' || result?.err === 'reservation_code_required') &&
          !reservationCode
        ) {
          console.warn('[checkLicense] Server requested redemption; delegating to validateLicense()');
          return this.validateLicense(typeof attempt === 'number' ? attempt + 1 : 1);
        }

        // Stale/expired: run revalidate flow once, then try validateLicense
        if (result?.err === 'already_in_use' || result?.err === 'ticket_expired') {
          try {
            await revalidate();
            if (Number.isNaN(parseInt(attempt))) attempt = 0;
            if (attempt++ < 2) {
              return this.validateLicense(attempt);
            } else {
              throw new Error('Failed to revalidate a stale ticket.');
            }
          } catch (err) {
            throw new Error(err.message || 'Revalidate failed');
          }
        }

        // Generic non-OK
        console.warn(result);
        throw new Error(`License server responded with status ${response.status}`);
      }

      if (result.message !== 'License is valid.') {
        throw new Error(`License validation failed: ${result.message}`);
      }

      // Mutual authentication: verify server signature
      if (useChallenge && result.serverSignature) {
        try {
          console.log('[checkLicense] Verifying server signature for mutual authentication');
          
          // Get the stadium's public key from the certificate chain
          const stadiumPublicKey = fullChain.issuingCertificate?.publicKey;
          if (!stadiumPublicKey) {
            throw new Error('Stadium public key not found in certificate chain');
          }

          // Verify the server's signature of the instanceId
          const isServerValid = this.#verifySignature(
            Buffer.from(this.#instanceId, 'utf8'),
            Buffer.from(result.serverSignature, 'hex'),
            stadiumPublicKey
          );

          if (!isServerValid) {
            throw new Error('Server signature verification failed - possible MITM attack');
          }

          console.log('[checkLicense] Server signature verified successfully');
        } catch (err) {
          console.error('[checkLicense] Mutual authentication failed:', err.message);
          throw new Error(`Mutual authentication failed: ${err.message}`);
        }
      }

      console.log('License validation (checkLicense) succeeded.');
    } catch (err) {
      console.warn('Error validating license (checkLicense)', err);
      throw new Error(`License validation failed: ${err.message}`);
    }
  }

  /**
   * Validates the license by performing local PKI validation and communicating with the license server.
   * @param {number} attempt - Current attempt number for retry logic
   * @param {boolean} useChallenge - Whether to use challenge-response authentication (default: true)
   * @throws Will throw an error if license validation fails.
   */
  async validateLicense(attempt = 0, useChallenge = true) {
    if (!this.#certificatePath || !this.#licenseServerUrl) {
      throw new Error('License validation configuration is incomplete.');
    }

    console.log({ certPath: this.#certificatePath });
    const certificateJson = readFile(this.#certificatePath);

    // Local PKI check
    const fullChain = JSON.parse(certificateJson);
    const isValidLocal = await this.#pki.validateTicket(fullChain);
    if (!isValidLocal) {
      throw new Error('Local certificate validation failed.');
    }

    // Include reservationCode if present (bbcertify path)
    let reservationCode = null;
    const reservationFile = path.join(TICKET_DIR, 'reservation.json');
    if (fs.existsSync(reservationFile)) {
      try {
        const resJson = JSON.parse(fs.readFileSync(reservationFile, 'utf8'));
        reservationCode = resJson?.reservationCode || null;
      } catch (e) {
        console.warn('Failed to load reservation file:', e);
      }
    }

    // Challenge-response authentication flow
    let challengeNonce = null;
    let nonceSignature = null;

    if (useChallenge) {
      try {
        // Step 1: Request challenge nonce from server
        const seatId = fullChain.seatCertificate?.seatData?.seatId;
        if (!seatId) {
          throw new Error('Cannot extract seatId from certificate chain');
        }

        console.log('[validateLicense] Requesting challenge nonce for seatId:', seatId);
        const challengeResponse = await fetchWithTimeoutAndRetry(
          `${this.#licenseServerUrl}/tickets/challenge`,
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ seatId }),
            agent: new https.Agent({ rejectUnauthorized: true }),
          }
        );

        if (!challengeResponse.ok) {
          throw new Error(`Failed to fetch challenge nonce: ${challengeResponse.status}`);
        }

        const challengeData = await challengeResponse.json();
        challengeNonce = challengeData.nonce;

        if (!challengeNonce) {
          throw new Error('Server did not provide a challenge nonce');
        }

        console.log('[validateLicense] Received challenge nonce');

        // Step 2: Sign the nonce with the ticket's Ed25519 private key
        const ticketPrivateKeyBase64 = fullChain.ticket?.ticketData?.jwk?.d;
        if (!ticketPrivateKeyBase64) {
          throw new Error('Ticket private key not found in certificate chain');
        }

        // Convert base64url to Buffer for Ed25519 signing
        const ticketPrivateKey = Buffer.from(ticketPrivateKeyBase64, 'base64url');
        const signature = await ed.signAsync(Buffer.from(challengeNonce, 'utf8'), ticketPrivateKey);
        nonceSignature = Buffer.from(signature).toString('hex');
        console.log('[validateLicense] Signed challenge nonce');
      } catch (err) {
        console.warn('[validateLicense] Challenge-response setup failed:', err.message);
        // Fallback to non-challenge mode if challenge fails
        useChallenge = false;
        challengeNonce = null;
        nonceSignature = null;
      }
    }

    const payload = {
      certificateJson: fullChain,
      instanceId: this.#instanceId,
      ...(reservationCode ? { reservationCode } : {}),
      ...(useChallenge && challengeNonce ? { challengeNonce, nonceSignature } : {})
    };

    console.log('[validateLicense] reservationCode present?', Boolean(reservationCode));
    console.log('[validateLicense] Using challenge-response?', useChallenge);

    const response = await fetchWithTimeoutAndRetry(
      `${this.#licenseServerUrl}/tickets/validate`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
        agent: new https.Agent({ rejectUnauthorized: true }),
      }
    );

    let result;
    try {
      result = await response.text();
      result = JSON.parse(result);
    } catch {
      console.warn({ result });
      throw new Error(`License server produced an invalid response with code ${response.status}`);
    }

    console.log('DEBUG: Server validation response details:', {
      status: response.status,
      err: result.err,
      message: result.message
    });

    if (!response.ok) {
      // If the server says we need a reservation code and we didn’t include one, try once more
      if (
        (result?.err === 'reserved_during_reval' || result?.err === 'reservation_code_required') &&
        !reservationCode
      ) {
        // Re-read just in case the reservation file was written between the start of this call and now.
        try {
          if (fs.existsSync(reservationFile)) {
            const resJson = JSON.parse(fs.readFileSync(reservationFile, 'utf8'));
            const code = resJson?.reservationCode;
            if (code) {
              console.warn('[validateLicense] retrying with reservationCode');
              if (Number.isNaN(parseInt(attempt))) attempt = 0;
              if (attempt++ < 2) {
                // Set it so the next run includes it
                return this.validateLicense(attempt, useChallenge);
              }
            }
          }
        } catch { /* noop */ }
      }

      // Stale/expired: run revalidate flow once or twice
      if (result?.err === 'already_in_use' || result?.err === 'ticket_expired') {
        try {
          await revalidate();
          if (Number.isNaN(parseInt(attempt))) attempt = 0;
          if (attempt++ < 2) {
            return this.validateLicense(attempt, useChallenge);
          } else {
            throw new Error('Failed to revalidate a stale ticket.');
          }
        } catch (err) {
          throw new Error(err.message || 'Revalidate failed');
        }
      }

      console.warn(result);
      throw new Error(`License server responded with status ${response.status}`);
    }

    if (result.message !== 'License is valid.') {
      throw new Error(`License validation failed: ${result.message}`);
    }

    // Mutual authentication: verify server signature
    if (useChallenge && result.serverSignature) {
      try {
        console.log('[validateLicense] Verifying server signature for mutual authentication');
        
        // Get the stadium's public key from the certificate chain
        const stadiumPublicKey = fullChain.issuingCertificate?.publicKey;
        if (!stadiumPublicKey) {
          throw new Error('Stadium public key not found in certificate chain');
        }

        // Verify the server's signature of the instanceId
        const isServerValid = this.#verifySignature(
          Buffer.from(this.#instanceId, 'utf8'),
          Buffer.from(result.serverSignature, 'hex'),
          stadiumPublicKey
        );

        if (!isServerValid) {
          throw new Error('Server signature verification failed - possible MITM attack');
        }

        console.log('[validateLicense] Server signature verified successfully');
      } catch (err) {
        console.error('[validateLicense] Mutual authentication failed:', err.message);
        throw new Error(`Mutual authentication failed: ${err.message}`);
      }
    }

    // Clean up reservation file on success
    if (reservationCode && fs.existsSync(reservationFile)) {
      try { fs.unlinkSync(reservationFile); } catch { /* ignore */ }
    }

    console.log('License validation (validateLicense) succeeded.');
  }

  /**
   * Releases the license by communicating with the license server.
   * @throws Will throw an error if license release fails.
   */
  async releaseLicense() {
    if (!this.#certificatePath || !this.#licenseServerUrl) {
      throw new Error('License release configuration is incomplete.');
    }
    const certificateJson = readFile(this.#certificatePath);
    const response = await fetchWithTimeoutAndRetry(
      `${this.#licenseServerUrl}/tickets/release`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ certificateJson, instanceId: this.#instanceId }),
        agent: new https.Agent({ rejectUnauthorized: true }),
      }
    );
    if (!response.ok) {
      throw new Error(`License server responded with status ${response.status}`);
    }
    console.log(`License released for instance ${this.#instanceId}.`);
   
    return await response.json();
  }

  /**
   * Securely marks time by invoking the timechain CLI.
   * @param {number} durationInSeconds - Duration in seconds for marking time.
   * @param {Function} callback - Callback to invoke when marking is complete.
   * @param {Object} [options] - Additional options for the timechain CLI.
   * @param {string} [options.proofType='nonce'] - 'nonce' or 'iteration'
   * @param {string} [options.outputFile] - Output file path for timechain results
   * @param {string} [options.useHash='rainstorm'] - 'rainstorm' or 'sha256'
   */
  async securelyMarkTime(durationInSeconds, callback, options = {}) {
    if (typeof durationInSeconds !== 'number' || durationInSeconds <= 0) {
      throw new Error('durationInSeconds must be a positive number.');
    }
    if (typeof callback !== 'function') {
      throw new Error('callback must be a function.');
    }
    const marks = 3;
    const {
      proofType = 'iteration',
      interval = `${Math.ceil(durationInSeconds/marks)} seconds`,
      outputFile = path.join(this.#appDir, 'timechain_proof.json'),
      useHash = 'rainstorm',
    } = options;
    if (!!this.#certificatePath) {
      const cert = this.#pki.getCertificate(this.#certificatePath);
      this.#pki.saveTicket(cert);
      this.#privateKeyPath = this.#pki.getTicketPrivateKeyPath();
    }
    if (!this.#privateKeyPath) {
      throw new Error('Private key path is required for signing.');
    }
    let cmd = `node ${path.resolve(DIST_DIR, 'timechain.js')} mark ` +
              `--out-file "${outputFile}" ` +
              `--proof-type "${proofType}" ` +
              `--interval "${interval}" ` +
              `--use-hash "${useHash}" ` +
              `--client-private-key "${this.#privateKeyPath}" ` +
              `--marks "${marks}" `;
    console.log(`Executing timechain command: ${cmd}`);
    DEBUG && fs.appendFileSync('tc.log', `\n\n${new Date}: ${cmd}`);
    return new Promise((resolve, reject) => {
      exec(cmd, { cwd: this.#appDir }, (error, stdout, stderr) => {
        if (error) {
          console.error('Timechain CLI error:', error);
          return reject(error);
        }
        try {
          callback();
          resolve({ stdout, stderr });
        } catch (cbError) {
          reject(cbError);
        }
      });
    });
  }
}

// fetch helpers
  async function fetchWithTimeoutAndRetry(resource, options = {}) {
    try {
      return await fetchWithTimeout(resource, options);
    } catch(e) {
      console.warn(`First fetch failed`, resource, e);
      console.info(`Will retry 1 time for`, resource);
      await sleep(2417);
      return fetchWithTimeout(resource, options);
    }
  }
  async function fetchWithTimeout(resource, options = {}) {
    const { timeout = 12000 } = options // default 12 seconds
    options.timeout = undefined;
    const controller = new AbortController();
    const id = setTimeout(() => controller.abort(), timeout);
    const response = await fetch(resource, {
      ...options,
      signal: controller.signal
    });
    clearTimeout(id);
    return response
  }
